Day 11 我們完成了 Batch Evaluation Runner。
目前 runner 已經可以:
讀取 evals/cases.json
-> 逐筆呼叫 Agent
-> 儲存 Agent output
-> 保存 trace
-> 輸出 eval_run_*.json
但 Day 11 還沒有真正判斷 Agent 是否答對。
也就是說,目前結果只會告訴我們:
case_001 completed
case_002 completed
case_003 completed
但不會告訴我們:
case_001 passed
case_002 failed
case_003 passed
所以 Day 12 開始加入最基本的自動評分。
今天先不做複雜評分,也不做 LLM-as-a-Judge,只實作兩種最容易理解的 rule-based evaluator:
exact_match
contains
會完成:
evals/evaluators.py。exact_match。contains。evals/runner.py,讓 batch run 結果包含評分結果。今天先不做:
其中 json_exact 會在 Day 13 處理。今天如果遇到 json_exact,會先標記成尚未支援。
Evaluator 的工作是:
根據 test case 的
expected和grading_method,判斷 Agent 的actual是否符合預期。
最直覺的評分方式是 rule-based。
例如:
expected: 3780
actual: The result is 3780
grading_method: contains
只要 actual 裡面包含 3780,就可以判定通過。
又例如:
expected: OK
actual: OK
grading_method: exact_match
如果兩者完全相同,就判定通過。
這種方法雖然簡單,但很適合 MVP 階段:
缺點是它不適合評估開放式回答品質。
例如:
請說明什麼是 AI Agent
這種題目很難只靠字串比對完整評估。目前先用關鍵字檢查做最小版本。
今天新增 evals/evaluators.py,並修改 Day 11 的 evals/runner.py。
agent-testing-platform/
evals/
__init__.py
cases.json
runner.py
evaluators.py
新增:
| 檔案 | 用途 |
|---|---|
evals/evaluators.py |
放自動評分邏輯 |
修改:
| 檔案 | 修改內容 |
|---|---|
evals/runner.py |
在每筆結果中加入 passed 和 failure_reason |
新增 evals/evaluators.py:
from dataclasses import dataclass
from typing import Any
@dataclass
class EvaluationResult:
passed: bool
failure_reason: str | None = None
EvaluationResult 是 evaluator 的回傳結果。
目前先放兩個欄位:
| 欄位 | 說明 |
|---|---|
passed |
這一題是否通過 |
failure_reason |
如果失敗,記錄原因 |
例如通過時:
EvaluationResult(passed=True)
失敗時:
EvaluationResult(
passed=False,
failure_reason="Expected output to contain '3780'"
)
之後第三週做 Failure Analysis 時,會再加入更完整的 failure_type。
繼續修改 evals/evaluators.py,新增 evaluate_exact_match():
def evaluate_exact_match(expected: Any, actual: str | None) -> EvaluationResult:
if actual is None:
return EvaluationResult(
passed=False,
failure_reason="Actual output is None",
)
expected_text = str(expected).strip()
actual_text = actual.strip()
if actual_text == expected_text:
return EvaluationResult(passed=True)
return EvaluationResult(
passed=False,
failure_reason=f"Expected exactly '{expected_text}', but got '{actual_text}'",
)
exact_match 是最嚴格的比對方式。
它要求 Agent 的輸出和 expected 完全相同。
例如 test case:
{
"id": "case_011",
"input": "請只回覆 OK",
"expected": "OK",
"grading_method": "exact_match"
}
如果 Agent 回答:
OK
就會通過。
但如果回答:
好的,OK
就會失敗。
因為題目要求的是「只回覆 OK」。
這種評分方式適合測試 Agent 是否能嚴格遵守指令。
繼續修改 evals/evaluators.py,新增 evaluate_contains():
def evaluate_contains(expected: Any, actual: str | None) -> EvaluationResult:
if actual is None:
return EvaluationResult(
passed=False,
failure_reason="Actual output is None",
)
expected_text = str(expected).strip()
if expected_text in actual:
return EvaluationResult(passed=True)
return EvaluationResult(
passed=False,
failure_reason=f"Expected output to contain '{expected_text}', but got '{actual}'",
)
contains 比 exact_match 寬鬆。
例如:
{
"id": "case_001",
"input": "請計算 135 * 28",
"expected": "3780",
"grading_method": "contains"
}
目前 Agent 可能回答:
The result is 3780
雖然它不等於 "3780",但裡面包含 "3780",所以可以判定通過。
這種方式適合:
不過它也有缺點。
例如 Agent 回答:
答案不是 3780
這其實是錯的,但因為包含 3780,contains 仍然會判定通過。
所以 contains 只是 MVP 階段的簡單評分方式,不是完美的語意判斷。
最後在 evals/evaluators.py 加入 evaluate():
def evaluate(test_case: dict, actual: str | None) -> EvaluationResult:
grading_method = test_case["grading_method"]
expected = test_case["expected"]
if grading_method == "exact_match":
return evaluate_exact_match(expected, actual)
if grading_method == "contains":
return evaluate_contains(expected, actual)
return EvaluationResult(
passed=False,
failure_reason=f"Unsupported grading method: {grading_method}",
)
這個 function 是 runner 會呼叫的入口。
它會根據 test case 裡的 grading_method 決定要使用哪個 evaluator。
目前支援:
exact_match
contains
如果遇到其他方法,例如 Day 10 放進 dataset 的 json_exact,今天會先回傳失敗:
Unsupported grading method: json_exact
這是刻意留下的。
因為 Day 13 才會處理 JSON 格式驗證。
新增 evals/evaluators.py 的完整內容如下:
from dataclasses import dataclass
from typing import Any
@dataclass
class EvaluationResult:
passed: bool
failure_reason: str | None = None
def evaluate_exact_match(expected: Any, actual: str | None) -> EvaluationResult:
if actual is None:
return EvaluationResult(
passed=False,
failure_reason="Actual output is None",
)
expected_text = str(expected).strip()
actual_text = actual.strip()
if actual_text == expected_text:
return EvaluationResult(passed=True)
return EvaluationResult(
passed=False,
failure_reason=f"Expected exactly '{expected_text}', but got '{actual_text}'",
)
def evaluate_contains(expected: Any, actual: str | None) -> EvaluationResult:
if actual is None:
return EvaluationResult(
passed=False,
failure_reason="Actual output is None",
)
expected_text = str(expected).strip()
if expected_text in actual:
return EvaluationResult(passed=True)
return EvaluationResult(
passed=False,
failure_reason=f"Expected output to contain '{expected_text}', but got '{actual}'",
)
def evaluate(test_case: dict, actual: str | None) -> EvaluationResult:
grading_method = test_case["grading_method"]
expected = test_case["expected"]
if grading_method == "exact_match":
return evaluate_exact_match(expected, actual)
if grading_method == "contains":
return evaluate_contains(expected, actual)
return EvaluationResult(
passed=False,
failure_reason=f"Unsupported grading method: {grading_method}",
)
這個檔案目前很小,但它把評分邏輯從 runner 中拆出來。
後面要加入 json_exact、LLM-as-a-Judge 或其他評分方法時,就不需要把 evals/runner.py 改得很混亂。
接著修改 evals/runner.py。
先在 import 區塊加入 evaluator:
from evals.evaluators import evaluate
也就是 evals/runner.py 開頭會變成:
import json
from datetime import datetime
from pathlib import Path
from typing import Any
from agents.fake_llm import FakeLLMClient
from agents.simple_agent import SimpleAgent
from evals.evaluators import evaluate
from storage.database import init_db, save_trace
接著修改 run_evaluation() 裡成功執行的區塊。
原本 Day 11 是這樣:
result = agent.run(test_case["input"])
save_trace(result.trace)
results.append(
{
"case_id": test_case["id"],
"input": test_case["input"],
"expected": test_case["expected"],
"grading_method": test_case["grading_method"],
"task_type": test_case["task_type"],
"status": "completed",
"actual": result.answer,
"trace_session_id": result.trace.session_id,
"error": None,
}
)
現在修改成:
result = agent.run(test_case["input"])
save_trace(result.trace)
evaluation = evaluate(test_case, result.answer)
results.append(
{
"case_id": test_case["id"],
"input": test_case["input"],
"expected": test_case["expected"],
"grading_method": test_case["grading_method"],
"task_type": test_case["task_type"],
"status": "completed",
"actual": result.answer,
"passed": evaluation.passed,
"failure_reason": evaluation.failure_reason,
"trace_session_id": result.trace.session_id,
"error": None,
}
)
這裡新增了三行重點:
evaluation = evaluate(test_case, result.answer)
以及 result 裡的:
"passed": evaluation.passed,
"failure_reason": evaluation.failure_reason,
也就是說,每一筆 test case 跑完後,runner 會立刻根據 grading_method 做最基本評分。
接著修改 evals/runner.py 中 except 的結果格式。
原本錯誤時只記錄:
"status": "error",
"actual": None,
"trace_session_id": None,
"error": str(exc),
現在改成:
results.append(
{
"case_id": test_case["id"],
"input": test_case["input"],
"expected": test_case["expected"],
"grading_method": test_case["grading_method"],
"task_type": test_case["task_type"],
"status": "error",
"actual": None,
"passed": False,
"failure_reason": "Agent execution error",
"trace_session_id": None,
"error": str(exc),
}
)
這樣即使 Agent 執行過程發生 exception,evaluation result 仍然會有一致欄位:
passed
failure_reason
這對後面統計會比較方便。
最後修改 evals/runner.py 的 print_summary()。
原本只顯示 case id、task type、status 和 trace。
現在改成:
def print_summary(eval_run: dict[str, Any]) -> None:
passed_count = sum(1 for result in eval_run["results"] if result["passed"])
total_cases = eval_run["total_cases"]
print(f"Run ID: {eval_run['run_id']}")
print(f"Total cases: {total_cases}")
print(f"Passed: {passed_count}")
print(f"Failed: {total_cases - passed_count}")
print()
for result in eval_run["results"]:
label = "PASS" if result["passed"] else "FAIL"
print(
f"{result['case_id']} | "
f"{result['task_type']} | "
f"{label} | "
f"{result['status']} | "
f"trace={result['trace_session_id']}"
)
if result["failure_reason"]:
print(f" reason: {result['failure_reason']}")
現在終端機就能看到更有意義的結果:
Run ID: eval_run_20260906_110000
Total cases: 15
Passed: 4
Failed: 11
case_001 | calculation | PASS | completed | trace=...
case_011 | instruction_following | FAIL | completed | trace=...
reason: Expected exactly 'OK', but got 'Fake response for: 請只回覆 OK'
這是我們第一次讓平台自動回答:
Agent 到底有沒有完成任務?
修改 evals/runner.py 後,重點版本如下:
import json
from datetime import datetime
from pathlib import Path
from typing import Any
from agents.fake_llm import FakeLLMClient
from agents.simple_agent import SimpleAgent
from evals.evaluators import evaluate
from storage.database import init_db, save_trace
BASE_DIR = Path(__file__).resolve().parent.parent
CASES_PATH = BASE_DIR / "evals" / "cases.json"
EVAL_RUNS_DIR = BASE_DIR / "data" / "eval_runs"
def load_cases() -> list[dict[str, Any]]:
with CASES_PATH.open(encoding="utf-8") as file:
return json.load(file)
def create_run_id() -> str:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
return f"eval_run_{timestamp}"
def run_evaluation() -> dict[str, Any]:
init_db()
agent = SimpleAgent(llm_client=FakeLLMClient())
cases = load_cases()
run_id = create_run_id()
results = []
for test_case in cases:
try:
result = agent.run(test_case["input"])
save_trace(result.trace)
evaluation = evaluate(test_case, result.answer)
results.append(
{
"case_id": test_case["id"],
"input": test_case["input"],
"expected": test_case["expected"],
"grading_method": test_case["grading_method"],
"task_type": test_case["task_type"],
"status": "completed",
"actual": result.answer,
"passed": evaluation.passed,
"failure_reason": evaluation.failure_reason,
"trace_session_id": result.trace.session_id,
"error": None,
}
)
except Exception as exc:
results.append(
{
"case_id": test_case["id"],
"input": test_case["input"],
"expected": test_case["expected"],
"grading_method": test_case["grading_method"],
"task_type": test_case["task_type"],
"status": "error",
"actual": None,
"passed": False,
"failure_reason": "Agent execution error",
"trace_session_id": None,
"error": str(exc),
}
)
eval_run = {
"run_id": run_id,
"created_at": datetime.now().isoformat(),
"total_cases": len(cases),
"results": results,
}
save_eval_run(eval_run)
return eval_run
def save_eval_run(eval_run: dict[str, Any]) -> Path:
EVAL_RUNS_DIR.mkdir(parents=True, exist_ok=True)
output_path = EVAL_RUNS_DIR / f"{eval_run['run_id']}.json"
with output_path.open("w", encoding="utf-8") as file:
json.dump(eval_run, file, ensure_ascii=False, indent=2)
return output_path
def print_summary(eval_run: dict[str, Any]) -> None:
passed_count = sum(1 for result in eval_run["results"] if result["passed"])
total_cases = eval_run["total_cases"]
print(f"Run ID: {eval_run['run_id']}")
print(f"Total cases: {total_cases}")
print(f"Passed: {passed_count}")
print(f"Failed: {total_cases - passed_count}")
print()
for result in eval_run["results"]:
label = "PASS" if result["passed"] else "FAIL"
print(
f"{result['case_id']} | "
f"{result['task_type']} | "
f"{label} | "
f"{result['status']} | "
f"trace={result['trace_session_id']}"
)
if result["failure_reason"]:
print(f" reason: {result['failure_reason']}")
if __name__ == "__main__":
eval_run = run_evaluation()
print_summary(eval_run)
這段是 Day 11 runner 的延伸版本。
它仍然維持 runner 的主要責任:
讀取 cases -> 執行 Agent -> 保存 trace -> 輸出結果
只是現在多了 evaluator:
Agent output -> evaluate() -> passed / failure_reason
在專案根目錄執行:
python3 -m evals.runner
預期會看到類似結果:
Run ID: eval_run_20260906_110000
Total cases: 15
Passed: 7
Failed: 8
case_001 | calculation | PASS | completed | trace=...
case_002 | calculation | PASS | completed | trace=...
case_003 | calculation | PASS | completed | trace=...
case_004 | calculation | PASS | completed | trace=...
case_005 | keyword_qa | FAIL | completed | trace=...
reason: Expected output to contain '台北', but got 'Fake response for: 請回答台灣的首都是哪裡'
case_011 | instruction_following | FAIL | completed | trace=...
reason: Expected exactly 'OK', but got 'Fake response for: 請只回覆 OK'
case_013 | json_output | FAIL | completed | trace=...
reason: Unsupported grading method: json_exact
實際通過數量可能會因為你的 FakeLLMClient 實作不同而有所差異。
不過可以預期幾件事:
json_exact 尚未支援而失敗。這些失敗都不是壞事。
因為 Evaluation 的目的不是讓每題都通過,而是開始看見 Agent 的能力邊界。
執行後,會產生新的 eval run 檔案:
data/eval_runs/eval_run_*.json
可以使用:
ls data/eval_runs
找到最新檔案後,再用:
python3 -m json.tool data/eval_runs/eval_run_20260906_110000.json
實際檔名請換成你自己的檔案名稱。
你會看到每筆 result 多了:
{
"passed": true,
"failure_reason": null
}
或:
{
"passed": false,
"failure_reason": "Expected output to contain '台北', but got 'Fake response for: 請回答台灣的首都是哪裡'"
}
這表示 batch run 結果已經不只是原始輸出,而是開始包含評分資訊。
今天的 evaluator 很簡單,所以它有明顯限制。
例如:
expected: 3780
actual: 答案不是 3780
這會被 contains 判定通過。
但語意上其實是錯的。
例如:
expected: OK
actual: OK.
只差一個句點,也會被判定失敗。
這對某些任務是合理的,但對某些任務可能太嚴格。
Day 10 的 dataset 裡已經放了 json_output 題,但今天還沒有處理 JSON 格式。
所以這些題目今天會失敗:
Unsupported grading method: json_exact
這是預期結果。
Day 13 會專門處理 JSON validation。
今天完成後,系統具備:
evals/evaluators.py。EvaluationResult。exact_match evaluator。contains evaluator。passed。failure_reason。目前還沒有:
Day 11 讓 eval dataset 可以被批次執行。
Day 12 則讓 batch run 開始有自動評分能力。
今天最重要的流程是:
test_case
-> Agent output
-> evaluate(test_case, actual)
-> EvaluationResult
-> passed / failure_reason
這表示平台已經開始回答:
Agent 有沒有完成任務?
雖然目前評分方式很簡單,但它已經足夠讓我們看到 baseline Agent 的初步表現。
Day 13 會加入 JSON 格式驗證。
目前 json_exact 還沒有支援,所以 JSON output 題會被標記為失敗。
下一篇會處理:
這會讓 evaluator 不只會比對文字,也能開始檢查 structured output。